Record and Playback Mouse and Keyboard Events - Felix John COLIBRI. |
- abstract : record and playback all mouse and keyboard messages : all windows messages are saved in a list (or a file) and can be replayed on the spot or later. A must for debugging VCL components, event logging,
keyboard macro, computer based training. Includes a readable format which can be used for SendKey / SendClicks replays, simulations or unit test.
- key words : record - playback - Windows Messages - windows hooks -
wh_JournalRecord - wh_JournalPlayback - SetWindowsHookEx - tEventMsg - CallNextHookEx - UnhookWindowsHookEx - ClientToScreen - virtual key codes
- software used : Windows XP Home, Delphi 6
- hardware used : Pentium 2.800Mhz, 512 M memory, 140 G hard disc
- scope : Delphi 1 to 2006, Turbo Delphi for Windows, Kylix
Delphi 5, 6, 7, 8 Delphi 2005, 2006, Turbo Delphi, Turbo 2007, Rad Studio 2007 to 2009, Delphi XE
- level : Delphi developer
- plan :
1 - Record and Playback for Debugging While developing a custom StringGrid based on a tPaintBox, we were using our
standard textual log. However to replay the script when some inconsitencies arose, we faced the task of simulating keyboard and mouse events. Certainly SendMessage could have been used, but figuring out how to build messages for
combinations of control keys (Alt, Shift, Control, Home etc) and mouse actions (right clic) was not that easy. So we decided to bite the bullet and build a reasonable record / playback system which could be used in a non invasive way during our test.
The specification was - record and replay keyboard events
- record and replay mouse events. Since our grid does not use mouse movement (we do not implement "onMouseOver" type of feedback, and resizing the
columns was not under scrutiny), recording mouse moves was not included. But this can be easily reintroduced (commenting out an If)
- be able to visualize the recorded messages (mainly for understanding the recording mechanism)
- to be able to replay as many time as desired the recorded messages
- be able to save, and reload the messages later, with the same application, but not necessarily at the same screen position
- be able to add messages manually, using a textual representation
2 - wh_JournalRecord and wh_JournalPlayback Windows Hooks 2.1 - Windows Hooks
Our system basically uses Windows Journal Hooks. They were specifically created for keyboard and mouse events record and playback. Hooks are a basic Windows functionality which enables us to ask Windows to call
one of our procedure. The same old technique already used in Interrupt redirection - we make a call to tell Windows where our callback is
- the callback is inserted in a (possibly empty) callback queue
- when the target event happens, Windows calls our procedure. We then
- perform whatever tasks we desire
- we usually call the next callback in the queue
There are several hook types. However, we are only interested here by the keyboard and mouse journaling hooks.
2.2 - wh_JournalRecord Windows Journaling hook We install the hook by calling
my_hook_handle:= SetWindowsHookEx(wh_JournalRecord,
my_journal_record_callback, hInstance, 0); | Where
- my_journal_record_callback the callback we have to write
- hInstance in the .EXE instance
- my_hook_handle will be used to later remove the hook from the chain
Once this hook is installed, Windows will call our callback whenever a message is removed from the message queue:
When a record journal hook is installed, each time a message is removed from the queue, our callback is called, with the parameters of the message. The
tasks of our callback is to store those messages somewhere (in memory, in a file, in a stream...)
Our callback will have the following header:
Function journal_record_callback(p_hook_code: integer;
m_w_param, m_l_param: Longint): Longint; Stdcall;
| where - p_hook_code is a "hook code", with the following values
- HC_SYSMODALOFF : a system-modal dialog box has been destroyed. The hook procedure must resume recording.
- HC_SYSMODALON : a system-modal dialog box is being displayed. Until the dialog box is destroyed, the hook procedure must stop recording.
- HC_ACTION : the other keyboard and mouse events.
- p_w_param is not used.
- p_l_param is a pointer to the message information
- the return value is not used
We are only interested in the hc_Action code. And in this case the p_l_param
parameter is a pointer to an tEventMsg structure containing information about a message removed from the system queue. The tEventMsg, defined in WINDOWS.PAS has the following definition:
Type PEventMsg = ^TEventMsg;
tEVENTMSG = Packed Record
message: UINT;
paramL: UINT;
paramH: UINT;
time: DWORD;
hwnd: HWND; End; |
where - message is the message code (wm_MouseDown, wm_KeyUp etc)
- paramL and paramH are parameters depending on the type of event
- time is the tick count of the event (allowing to detect double clicks)
- hwnd is the handle of the window to which the message was posted. It allows Windows to set the focus
In the case of our events
- for the mouse events
- the low word of paramL contains the SCREEN x position
- the low word of paramH contains the SCREEN y position
- for keyboard events
- only wm_KeyDown and wm_KeyUp are captured (the wm_Char is generated by the TranslateMessage in the message loop)
- the virtual key code is in the lo byte of paramL
The tasks of our callback are : - to store the tEventMsg somewhere, to be in a position to fetch it back for replay
- to call the next possible hook callback. This is done inside our callback by calling:
Result:= CallNextHookEx(my_hook_handle, p_hook_code,
p_w_param, p_l_param); | - we are supposed to handle the vk_Cancel virtual key code, which is
triggered by typing Ctrl+ Break, and we should terminate the journaling when we receive this key combination.
We can also specify other key combinations in order to stop recording. In
our application, we trigger the end by clicking on an tButton. We also noticed that within the IDE, Ctrl Alt Del also stops the journaling
To stop the recording, we remove the journal hook :
UnhookWindowsHookEx(my_hook_handle); |
Finally, note that
- the hook is system wide. It cannot be used as thread specific
- the callback is always handled in the context of our thread
More importantly
- the tEventMsg message parameters are the "raw unprocessed" windows message informations.
- the normal driver processing will then take into account the national keyboard, the currently typed keys like Shift, Alt etc to qualify the raw
parameters. vk_A might become "a", "A", "â" etc. Similarily the mouse events do not contain any p_button parameter or shift states or other values we usually find in OnKeyDown or OnMouseDown Delphi events.
- so the tMessageEvent are the raw data before those processing. We store those, and during playback, the raw parameters will be reinjected just before the driver processing, thus producing the very nice event parameters we expect in Delphi
2.3 - wh_JournalPlayback Windows Journaling hook Replaying the recorded events is symmetric to recording.
Please note that
- to replay the same tEventMessage several time, we simply keep the same value in our tEventMessage (HC_SKIP does not grab the next message, but keeps the current one for some iteration).
- to sleep between the replay of a message
- in a first callback, in HC_GETNEXT we return the wait tick count.
- after the pause, Windows calls back our procedure, and in HC_GetNext we now should return 0
This could be used to slow the replay down - the callback is always handled in the context of our thread (the one which installed the wh_JournalReplay). It can be in our project, or in a Library (a .DLL)
- If the user presses Ctrl+Esc or Ctrl+Alt+Del during journal playback, the system stops the playback, unhooks the journal playback procedure, and posts a wm_CancelJournal message to the journaling application.
- if the hook procedure returns a message in the range wm_KeyFirst to wm_KeyLast, the following conditions apply:
- tEventMsg.paramL specifies the virtual key code of the key that was pressed.
- tEventMsg.paramH specifies the scan code (the keyboard touch number).
- there's no way to specify a repeat count. The event is always taken to represent one key event.
3 - Delphi Mouse and Keyboard event Journal and Replay 3.1 - Overall architecture - the c_message_list is a tStringList container which stores the
tEventMessages (in memory or disc)
- to be able to replay the messages from a disc file, we must be able to set the window handles of the controls in the application which reloads the
messages. Our c_win_control_list is in charge of mapping the control names to the Windows handles
- our c_record_playback_message Class is in charge of launching the record and playback, and contains the two callbacks
- finally a c_message_list_parser is able to parse a textual tEventMsg representation
3.2 - The UML class diagram
3.3 - The tWinControl list This list is an auxiliary structure used to get the window handle of a control (say Button3) from its name ('Button3') and get the name and the control from is handle. The definitions are:
Type c_win_control= // one "win_control"
Class(c_basic_object)
m_c_wincontrol_ref: tWinControl;
Function f_screen_x: Integer;
Function f_screen_y: Integer;
End; // c_win_control
c_win_control_list= // "win_control" list
Class(c_basic_object)
m_c_win_control_list: tStringList;
Constructor create_win_control_list(p_name: String);
Procedure add_win_control(p_win_control_name: String;
p_c_win_control: c_win_control);
Procedure build_wincontrol_list(p_c_control: tWinControl);
Function f_c_find_win_control_by_handle(p_handle: tHandle): c_win_control;
Function f_handle_to_name(p_handle: tHandle): String;
Function f_name_to_handle(p_control_name: String): tHandle;
End; // c_win_control_list |
The structure is loaded by a recursive procedure using the Controls array:
Procedure c_win_control_list.build_wincontrol_list(p_c_control: tWinControl);
Procedure _build_wincontrol_list_recursive(p_c_control: tWinControl);
Var l_child_control_index: integer; Begin
With p_c_control Do Begin
With f_c_add_win_control(Name) Do
m_c_wincontrol_ref:= p_c_control;
For l_child_control_index:= 0 To ControlCount- 1 Do
If Controls[l_child_control_index] Is tWinControl
Then _build_wincontrol_list_recursive(p_level+ 1,
tWinControl(Controls[l_child_control_index]));
End; // with p_c_control
End; // _build_wincontrol_list_recursive
Begin // build_wincontrol_list _build_wincontrol_list_recursive(p_c_control);
End; // build_wincontrol_list | and the f_screen_x and f_scree_y are used to compute the current SCREEN
position of the top left corner of any control on our form :
Function c_win_control.f_screen_x: Integer; Begin
With m_c_wincontrol_ref Do
Result:= ClientToScreen(Point(Left, Top)).X;
End; // f_screen_x |
3.4 - The message list The message list is defined by:
Type t_message=
Packed Record
m_wm_code: Cardinal;
m_w_param: Longint;
m_l_param: Longint;
m_time: DWORD;
m_window_handle: HWND;
End; // t_message
// -- to get the Windows messages while recording
t_pt_message= ^t_message;
// -- for restoring control handle from (file saved) control name
t_message_with_control_name= Packed Record
m_message: t_message;
m_control_name: String[107];
End; // t_message_with_control_name
c_message= // one "message"
Class(c_basic_object)
m_message: t_message;
Constructor create_message(p_name: String);
Function f_display_message: String;
Function f_is_mouse_message: Boolean;
End; // c_message
c_message_list= // "message" list
Class(c_basic_object)
m_c_message_list: tStringList;
m_c_win_control_list: c_win_control_list;
// -- for relative tick count computations
m_start_tick_count: Integer;
Constructor create_message_list(p_name: String;
p_c_win_control: tWinControl);
Function f_c_add_message(p_message_name: String): c_message;
Function f_c_add_recorded_message(p_pt_message: t_pt_message): c_message;
Function f_c_add_a_message(p_name: String;
p_wm_code: Cardinal; p_w_param, p_l_param: LongInt;
p_time: DWORD; p_window_handle: HWND): c_message;
Procedure compute_relative_tick_count_and_mouse_xy;
Procedure save_to_file_with_control_name(p_full_file_name: String);
Procedure load_from_file_with_control_name(p_full_file_name: String);
End; // c_message_list |
And:
- the t_message has the same structure as the tEventMsg
- the t_pt_message pointer is used to cast the p_l_param in the record and replay callback
- t_message_with_control_name is a t_message along with the control name.
Basically the t_message contains the window handle at the time of recording. If we replay during the same run this window handle is valid.
However, if we save the messages on disc and reload them during another execution, the handle will no longer valid. We must somehow change the handles to match the handles of the actual controls. Therefore
- when we save the messages, we naturally save the t_message parameters, and also the control name (say 'Edit3', or 'PaintBox5'.
This name is looked up using the c_win_control_list. This list is built
when we create the c_message_list, and used when we save the message list - whenever we reload the message from a file
- we create a c_message_list, and rebuild the c_win_control_list with
the current controls of the application
- for each message read from the disc, we read the control name ('Panel18') and compute the window handle of the controls in this application. This is only required for mouse messages. Hence the
c_message.f_is_mouse_message function
- the c_message_list
To recap, - after the recording ends, the relative time and mouse positions are computed
- the message list is saved after recording
- therefore, after recording, the in-memory or the possibly saved message list always contain relative value
- at replay time, the absolute values are recomputed, using the current tick count as well as the current control positions
Please note - the time value seems irrelevant. Computing the "nearly" actual time by adding back GetTickCount is not necessary. Running the list with relative
times, or even with 0 time values (as we do for the manually crafted messages below) has the same result. However, we could use the relative values to inject some delays between the messages (return a non zero values from the callback)
3.5 - The record and playback Class The definition is :
Type t_on_notify= Procedure(); c_record_playback_message=
Class(c_basic_object)
m_c_message_list: c_message_list;
m_hook_handle: hHook;
m_is_recording: Boolean;
m_replay_start_index, m_replay_end_index: word;
m_is_playing: Boolean;
m_replay_index: Integer;
m_c_next_message: c_message;
m_on_notify: t_on_notify;
Constructor create_record_playback_message(p_name: String;
p_c_win_control: tWinControl);
// -- record / playback
Procedure start_recording;
Procedure stop_recording;
Procedure get_next_message(p_replay_index: Integer);
Function f_replay_message: t_replay_error_type;
Procedure stop_playback;
Destructor Destroy; Override;
End; // c_record_playback_message | and:
- the recorded message list is managed by c_message_list
- when we install the hook (a call to start_recording), the returned handle is saved in m_hook_handle. At the same time, m_is_recording is toggled to
true to avoid recursive recording
- before replaying, we initialize m_replay_start_index and m_replay_end_index:
- by default the values are 0 and the message count- 1
- however we might want to skip some initial messages (while tracking a bug), and usually we remove the last two messages which are the click on the "stop_recording" button
- when we call start_replay
- the current message index m_replay_index is initialized to m_replay_start_index, and m_is_playing is initialized to True
- each time hc_Skip is reached
- if m_replay_index is greater than m_replay_end_index, we call stop_playback
- if not we get the the next message (make a copy of the current message and adjust the relative time and position values) and increment the
current index
- m_on_notify is a callback event that we used to display the current message being replayed
Our record playback procedure is:
Function journal_record_callback(p_hook_code: integer;
p_w_param, p_l_param: Longint): Longint; Stdcall;
Var l_pt_message: t_pt_message; Begin
Result:= 0;
With f_c_record_playback_message(Nil) Do
Begin // -- optionally stop recording when type Pause / Break
If GetKeyState(vk_Pause)< 0
Then Begin
stop_recording;
Result:= CallNextHookEx(m_hook_handle,
p_hook_code, p_w_param, p_l_param);
Exit; End;
Case p_hook_code Of
HC_ACTION : Begin
l_pt_message:= t_pt_message(p_l_param);
With l_pt_message^ Do
If (m_wm_code<> wm_MouseMove) And (m_wm_code<> $FF)
Then m_c_message_list.f_c_add_recorded_message(l_pt_message);
End; // HC_ACTION Else
// -- call next hook in chain
Result:= CallNextHookEx(m_hook_handle, p_hook_code, p_w_param, p_l_param);
End; // case p_hook_code
End; // with f_c_record_playback_message(Nil)
End; // journal_record_callback |
And our replay callback is :
Function journal_playback_callback(p_hook_code: integer;
p_w_param, p_l_param: Longint): Longint; Stdcall;
Begin
With f_c_record_playback_message(Nil) Do
Case p_hook_code Of
HC_SKIP: Begin
// -- increment message counter
inc(m_replay_index);
// -- check to see if all messages have been played
If m_replay_index>= m_replay_end_index
Then stop_playback
Else
With m_c_message_list Do
get_next_message(m_replay_index);
Result:= 0; End;
HC_GETNEXT: Begin
// -- move message in buffer to message queue
t_pt_message(p_l_param)^:= m_c_next_message.m_message;
// -- process immediately (no delay)
Result:= 0;
If Assigned(m_on_notify)
Then m_on_notify;
End Else
// -- call next hook in chain
Result:= CallNextHookEx(m_hook_handle,
p_hook_code, p_w_param, p_l_param);
End; // case p_hook_code
End; // journal_playback_callback |
Finally, we compute the next message to replay with:
Procedure c_record_playback_message.get_next_message(p_replay_index: Integer);
// -- dynamically recompute the absolute tick and, if mouse, xy
// -- => allows to replay any number of time Begin
// -- copy the message from the message store
With m_c_message_list.f_c_message(p_replay_index) Do
m_c_next_message.m_message:= m_message;
// -- compute the current SCREEN position
With m_c_next_message, m_message Do
Begin If f_is_mouse_message
Then
With m_c_message_list.m_c_win_control_list
.f_c_find_win_control_by_handle(m_window_handle) Do
Begin
Inc(m_w_param, f_screen_x);
Inc(m_l_param, f_screen_y);
End;
Inc(m_time, m_c_message_list.m_start_tick_count);
End; End; // get_next_message | And
- before installing the journal replay hook, we call get_next_message(0)
- in each playback call, we increment the index and call this method again
Please note that
- the message used for playback is contained in a separate variable. We copy the values of the current message in this structure and adjust this variable to get the absolute time and screen position
- it was a design decision to recompute the values for each run in a separate variable instead of directly modifying the values in the message list.
This former solution required using booleans to remember whether the values
were absolute or relative, and quickly became a mess. And copying 20 bytes today is not such a big deal
Displaying the messages Our message list contains all kind of display function that you can call at any
time (while recording, after the recording has stopped, after reloading the saved messages, while replaying etc)
3.6 - Forging messages We can manually build messages, by using any kind of message syntax. We must be
able to build tEventMsg information : - for keyboard message, we simply convert some textual information (say "Return" or "A" into the binary wm_code, wParam and lParam
- for mouse messages, we have to build the wm_code, then screen position and the window handle
We chose a readable, although somehow cumbersome, syntax, which is the following
There for, to create a message which - selects Edit3
- types "a"
- goes to the next control (assuming it is another edit), by typing "Tab"
- goes at the start of the edit (avoiding to overwrite the AutoSelected Text) by typing "Home"
- typing "b"
we would call:
With my_c_message_list_parser Do
my_message_text:= ''
+ f_relative_mouse_text(k_left_mouse_down_name, 10, 2, 0, 'Edit3')
+ f_relative_mouse_text(k_left_mouse_up_name, 10, 2, 0, 'Edit3')
+ 'a' + f_relative_virtual_key_code_text(vk_tab, 400)
+ f_relative_virtual_key_code_text(vk_home, 400) + 'b' ; |
which will create the following text (line break added) :
µvk_left_mouse_down,10,2,0,Edit3µµvk_left_mouse_up,10,2,0,Edit3µ aµvk_tab,400µµvk_home,400µb |
This line is parsed by calling parse_relative_message_string, and the resulting c_message_list can be replayed, saved, reloaded etc, like any other recorded message. The detail of the parsing in included in the
downloadable .ZIP
3.7 - The Delphi Journaling Hook demo Here is a quick demo :
In any case, remember, if your are stuck, Ctrl Alt Del is your best friend !
To demonstrate the textual message feature
Just for the record, writing this little app took us 2 days, plus one for the article. Enjoy !
4 - Improvements Among the possible improvements:
- provide for a multi-form journaling. This would require saving the form name along with the control name in the win control list
- use a more concise textual representation for messages (like "~" for Ctrl, "@" for Alt etc)
- implement repeat or delay features
- add automatic unhook procedures, at least if we quit the project
- our tWinControl structure could have been optimized (sorted, for instance),
and maybe before replay we could include in each c_message a reference to the tWinControl to avoid lookups
5 - Download the Sources Here are the source code files:
- mouse_and_keyboard_record_and_playback.zip: the project with
- the win control list
- the message list
- the record / playback manager
- the textual message parser
mouse_and_keyboard_record_and_playback.zip creating Size : (38 K) The .ZIP file(s) contain: - the main program (.DPR, .DOF, .RES), the main form (.PAS, .DFM), and any other auxiliary form
- any .TXT for parameters, samples, test data
- all units (.PAS) for units
Those .ZIP - are self-contained: you will not need any other product (unless expressly mentioned).
- for Delphi 6 projects, can be used from any folder (the pathes are RELATIVE)
- will not modify your PC in any way beyond the path where you placed the .ZIP (no registry changes, no path creation etc).
To use the .ZIP:
- create or select any folder of your choice
- unzip the downloaded file
- using Delphi, compile and execute
To remove the .ZIP simply delete the folder.
The Pascal code uses the Alsacian notation, which prefixes identifier by program area: K_onstant, T_ype, G_lobal, L_ocal, P_arametre, F_unction, C_lass etc. This notation is presented in the Alsacian Notation paper. The .ZIP file(s) contain:
- the main program (.DPROJ, .DPR, .RES), the main form (.PAS, .ASPX), and any other auxiliary form or files
- any .TXT for parameters, samples, test data
- all units (.PAS .ASPX and other) for units
Those .ZIP
- are self-contained: you will not need any other product (unless expressly mentioned).
- will not modify your PC in any way beyond the path where you placed the .ZIP
(no registry changes, no path outside from the container path creation etc).
To use the .ZIP: - create or select any folder of your choice.
- unzip the downloaded file
- using Delphi, compile and execute
To remove the .ZIP simply delete the folder. The Pascal code uses the Alsacian notation, which prefixes identifier by program area: K_onstant, T_ype, G_lobal, L_ocal, P_arametre,
F_unction, C_lass etc. This notation is presented in the Alsacian Notation paper.
As usual:
- please tell us at fcolibri@felix-colibri.com if you found some errors, mistakes, bugs, broken links or had some problem downloading the file. Resulting corrections will
be helpful for other readers
- we welcome any comment, criticism, enhancement, other sources or reference suggestion. Just send an e-mail to fcolibri@felix-colibri.com.
- or more simply, enter your (anonymous or with your e-mail if you want an answer) comments below and clic the "send" button
- and if you liked this article, talk about this site to your fellow developpers, add a link to your links page ou mention our articles in
your blog or newsgroup posts when relevant. That's the way we operate: the more traffic and Google references we get, the more articles we will write.
6 - References Here are a couple of references
7 - The author Felix John COLIBRI works at the Pascal
Institute. Starting with Pascal in 1979, he then became involved with Object Oriented Programming, Delphi, Sql, Tcp/Ip, Html, UML. Currently, he is mainly
active in the area of custom software development (new projects, maintenance, audits, BDE migration, Delphi
Xe_n migrations, refactoring), Delphi Consulting and Delph
training. His web site features tutorials, technical papers about programming with full downloadable source code, and the description and calendar of forthcoming Delphi, FireBird, Tcp/IP, Web Services, OOP / UML, Design Patterns, Unit Testing training sessions. |